#--
# Active Directory Module for Ruby
#
# Copyright (c) 2005-2006 Justin Mecham
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#++

module ActiveDirectory

  #
  # The ActiveDirectory module contains the classes used for communicating with
  # Active Directory LDAP servers. ActiveDirectory::Base is the basis from which
  # all classes derive.
  #
  class Base

    #
    # Configuration
    #
    @@server_settings = {
      :host     => "localhost",
      :port     => 389,
      :username => nil,
      :password => nil,
      :domain   => nil,
      :base_dn  => nil
    }
    cattr_accessor :server_settings

    #
    # Logging
    #
    cattr_writer :logger
    def self.logger
      @@logger || Logger.new(STDOUT)
    end

    #
    # Returns a connection to the configured Active Directory instance. If a
    # connection is not already established, it will attempt to establish the
    # connection.
    #
    def self.connection
      @@connection ||= connect
    end

    #
    # Opens and returns a connection to the Active Directory instance. By
    # default, secure connections will be attempted first while gracefully
    # falling back to lesser secure methods.
    #
    # The order by which connections are attempted are: TLS, SSL, unencrypted.
    #
    # Calling #connection will automatically call this method if a connection
    # has not already been established.
    #
    def self.connect

      host = @@server_settings[:host]
      port = @@server_settings[:port] || 389

      # Attempt to connect using TLS
      begin
        connection = LDAP::SSLConn.new(host, port, true)
        connection.set_option(LDAP::LDAP_OPT_PROTOCOL_VERSION, 3)
        bind(connection)
        logger.info("ActiveDirectory: Connected to #{@@server_settings[:host]} using TLS...") unless logger.nil?
      rescue
        logger.debug("ActiveDirectory: Failed to connect to #{@@server_settings[:host]} using TLS!") unless logger.nil?
        # Attempt to connect using SSL
        begin
          connection = LDAP::SSLConn.new(host, port, false)
          connection.set_option(LDAP::LDAP_OPT_PROTOCOL_VERSION, 3)
          bind(connection)
          logger.info("ActiveDirectory: Connected to #{@@server_settings[:host]} over SSL...") unless logger.nil?
        rescue
          logger.debug("ActiveDirectory: Failed to connect to #{@@server_settings[:host]} over SSL!") unless logger.nil?
          # Attempt to connect without encryption
          begin
            connection = LDAP::Conn.new(host, port)
            connection.set_option(LDAP::LDAP_OPT_PROTOCOL_VERSION, 3)
            bind(connection)
            logger.info("ActiveDirectory: Connected to #{@@server_settings[:host]} without encryption!") unless logger.nil?
          rescue
            logger.error("ActiveDirectory: Failed to connect to #{@@server_settings[:host]}!") unless logger.nil?
            puts "EXCEPTION: #{$!}"
            connection = nil
            raise
          end
        end
      end

      connection.set_option(LDAP::LDAP_OPT_REFERRALS, 0)

      connection
    
    end

    #
    # Unbinds (if bound) and closes the current connection.
    #
    def self.close
      begin
        @@connection.unbind unless @@connection.nil?
      rescue
      end
      @@connection = nil
    end

    #
    # Attempts to reconnect to the server by closing the existing connection
    # and reconnecting.
    #
    def self.reconnect
      close
      @@connection = connect
    end

    #
    # Search interface for querying for objects within the directory, such as
    # users and groups. Searching the directory is quite similar to a normal
    # LDAP search, but with a better API.
    #
    # If you call this method from User or Group, it will narrow your searches
    # to those specific objects by default. Calling this method on Base will
    # return objects of any class and provides the most flexibility.
    #
    # ==== Searching Users
    #
    # Users may be located within the directory by their distinguished name
    # (DN), their username (sAMAccountName), or through any other valid LDAP
    # filter and optional search base.
    #
    #  # Load all users (including disabled) within the default Base DN.
    #  all_users      = ActiveDirectory::User.find(:all)
    #
    #  # Load all disabled users within the default Base DN.
    #  disabled_users = ActiveDirectory::User.find(:all,
    #                     :filter => "(userAccountControl=514)")
    #
    #  # Load all users who are in the Managers organizational unit whose
    #  # accounts are not disabled.
    #  managers       = ActiveDirectory::User.find(:all,
    #                     :base   => "OU=Managers,DC=example,DC=com",
    #                     :filter => "(userAccountControl=512)")
    #
    #  # Load the user "John Doe" by his sAMAccountName.
    #  user = ActiveDirectory::User.find("jdoe")
    #
    #  # Load the user "John Doe" by his distinguished name (DN).
    #  user = ActiveDirectory::User.find("CN=John Doe,CN=Users,DC=example,DC=com")
    #
    # ==== Searching Groups
    #
    # Groups may be located within the diretctory by their distinguished name
    # (DN) or through any other valid LDAP filter and optional search base.
    #
    #  # Load all groups within the default Base DN.
    #  all_groups = ActiveDirectory::Group.find(:all)
    #
    #  # Load the "Developers" group by its distinguished name (DN).
    #  developers = ActiveDirectory::Group.find("CN=Developers,DC=example,DC=com")
    #
    # ==== More Advanced Examples
    #
    # By calling ActiveDirectory::Base#find you can query objects across
    # classes, allowing you to pull in both Groups and Users that match your
    # criteria.
    #
    #  # Load all Contacts
    #  contacts = ActiveDirectory::Base.find(:all,
    #               :filter => "(&(objectClass=User)(objectCategory=Contact))")
    #
    def self.find(*args)

      options    = extract_options_from_args!(args)
      attributes = [ "distinguishedName", "objectClass" ]

      # Determine the appropriate search filter
      if self.name == "ActiveDirectory::Base"
        if options[:filter]
          filter = options[:filter]
        else
          filter = "(|(&(objectCategory=Person)(objectClass=User))(objectClass=Group))"
        end
      else
        subklass = class_name_of_active_directory_descendent(self)
        if subklass == "ActiveDirectory::User"
          filter = "(&(objectCategory=Person)(objectClass=User)#{options[:filter]})"
        elsif subklass == "ActiveDirectory::Group"
          filter = "(&(objectClass=Group)#{options[:filter]})"
        end
      end

      # Determine the appropriate search base
      base_dn = options[:base] ? options[:base] : @@server_settings[:base_dn]

      # Determine the appropriate scope
      scope = options[:scope] ? options[:scope] : LDAP::LDAP_SCOPE_SUBTREE

      # Load all matching objects
      if args.first == :all

        logger.debug "Searching Active Directory" \
                     " - Filter: #{filter}" \
                     " - Search Base: #{base_dn} " \
                     " - Scope: #{scope}"

        # Perform search
        entries = self.search(base_dn, scope, filter, attributes)

        result = Array.new
        unless entries.nil?
          for entry in entries
            if entry['objectClass'].include? "person"
              result << User.new(entry['distinguishedName'][0])
            elsif entry['objectClass'].include? "group"
              result << Group.new(entry['distinguishedName'][0])
            end
          end
        end
        result

      else

        # Load a single matching object by either a common name or a
        # sAMAccountName
        if args.first =~ /(CN|cn)=/
          base_dn = args.first
        else
          filter = "(&(objectCategory=Person)(objectClass=User)(samAccountName=#{args.first})#{options[:filter]})"
        end

        logger.debug "Searching Active Directory" \
                     " - Filter: #{filter}" \
                     " - Search Base: #{base_dn} " \
                     " - Scope: #{scope}"

        begin
          entry = self.search(base_dn, scope, filter, attributes)
        rescue
          if $!.message == "No such object"
            raise UnknownUserError
          else
            raise
          end
        end

        entry = entry[0]
        if entry['objectClass'].include? "person"
          User.new(entry['distinguishedName'][0])
        elsif entry['objectClass'].include? "group"
          Group.new(entry['distinguishedName'][0])
        end

      end

    end

  protected

    #
    # Implement our own interface to LDAP::Conn#search so that we can attempt
    # graceful reconnections when the connection becomes stale or otherwise
    # terminates.
    #
    def self.search(base_dn, scope, filter, attrs = nil) #:nodoc:
      retries = 0
      entries = nil
      begin
        connection.search(base_dn, scope, filter, attrs) { |entry|
          entries = Array.new if entries.nil?
          entries << entry.to_hash
        }
      rescue
        logger.debug("ActiveDirectory: Attempting to re-establish connection to Active Directory server... (Exception: \"#{$!}\" – Retry ##{retries} – #{$!.class})}") unless logger.nil?
        begin
          reconnect
        rescue
          (retries += 1) < 3 ? retry : raise
        end
        retry
      end
      entries
    end

  private

    #
    # Attempts to bind to the Active Directory instance. If a bind attempt
    # fails by simple authentication an attempt at anonymous binding will be
    # made.
    #
    def self.bind(connection)
      # Attempt to bind with a username and password
      begin
        connection.bind("#{@@server_settings[:username]}@#{@@server_settings[:domain]}", @@server_settings[:password])
        logger.info("ActiveDirectory: Bound to Active Directory server as #{@@server_settings[:username]}.") unless logger.nil?
      rescue
        logger.debug("ActiveDirectory: Failed to bind to Active Directory server as \"#{@@server_settings[:username]}\"!") unless logger.nil?
        # Attempt to bind anonymously
        begin
          connection.bind()
          logger.info("ActiveDirectory: Bound anonymously to Active Directory server.") unless logger.nil?
        rescue
          logger.debug("ActiveDirectory: Failed to bind anonymously to Active Directory server!") unless logger.nil?
          raise
        end
      end
    end
  
    def self.extract_options_from_args!(args)
      options = args.last.is_a?(Hash) ? args.pop : {}
      validate_find_options(options)
      options
    end

    def self.validate_find_options(options)
      options.assert_valid_keys [ :base, :filter, :scope ]
    end

    def self.class_of_active_directory_descendent(klass)
      if klass.superclass == Base
        klass
      elsif klass.superclass.nil?
        raise ActiveDirectoryError, "#{name} doesn't belong in a class \
          hierarchy descending from ActiveDirectory"
      else
        self.class_of_active_directory_descendent(klass.superclass)
      end
    end

    def self.class_name_of_active_directory_descendent(klass)
      self.class_of_active_directory_descendent(klass).name
    end

  end

  class ActiveDirectoryError < StandardError #:nodoc:
  end

  class UnknownUserError < StandardError #:nodoc:
  end

  class UnknownGroupError < StandardError #:nodoc:
  end

  class PasswordInvalid < StandardError #:nodoc:
  end

end